Задачи проекта: Используя данные интернет-магазина приоритезировать гипотезы, произвести оценку результатов A/B-тестирования различными методами
Ключевые слова: A/B-тест, статистический тест, фреймворк, RICE, ICE
Навыки и инструменты: A/B-тестирование, Matplotlib, Pandas, Python, SciPy, проверка статистических гипотез
Описание проекта: Проведена приоритизация гипотез по фреймворкам ICE и RICE. Затем провел анализ результатов A/B-теста, построил графики кумулятивной выручки, среднего чека, конверсии по группам, а затем посчитал статистическую значимость различий конверсий и средних чеков по сырым и очищенным данным. На основании анализа мной было принято решение о нецелесообразности дальнейшего проведения теста.
Подготовить список гипотез для увеличения выручки крупного интернет-магазина. Приоритизировать гипотезы, запустить A/B-тест и проанализировать результаты.
Импорт библиотек
import pandas as pd
import matplotlib.pyplot as plt
from plotly import graph_objects as go
from scipy import stats as st
import numpy as np
import math as mth
Преобразование типов, исследование пропущенных значений и дубликатов
marketing_events = pd.read_csv('/datasets/ab_project_marketing_events.csv')
new_users = pd.read_csv('/datasets/final_ab_new_users.csv')
events = pd.read_csv('/datasets/final_ab_events.csv')
ab_participants = pd.read_csv('/datasets/final_ab_participants.csv')
marketing_events.head(5)
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
marketing_events.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null object 3 finish_dt 14 non-null object dtypes: object(4) memory usage: 576.0+ bytes
marketing_events['start_dt']=pd.to_datetime(marketing_events['start_dt'])
marketing_events['finish_dt']=pd.to_datetime(marketing_events['finish_dt'])
marketing_events.duplicated().sum()
0
new_users.head(5)
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
| 3 | 50734A22C0C63768 | 2020-12-07 | EU | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-07 | N.America | iPhone |
new_users.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB
new_users['first_date']=pd.to_datetime(new_users['first_date'])
new_users.duplicated().sum()
0
new_users['user_id'].duplicated().sum()
0
events.head()
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 |
| 3 | 96F27A054B191457 | 2020-12-07 04:02:40 | purchase | 4.99 |
| 4 | 1FD7660FDF94CA1F | 2020-12-07 10:15:09 | purchase | 4.99 |
events.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB
events['event_dt']=pd.to_datetime(events['event_dt'])
events.duplicated().sum()
0
ab_participants.head()
| user_id | group | ab_test | |
|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test |
| 1 | A7A3664BD6242119 | A | recommender_system_test |
| 2 | DABC14FDDFADD29E | A | recommender_system_test |
| 3 | 04988C5DF189632E | A | recommender_system_test |
| 4 | 482F14783456D21B | B | recommender_system_test |
ab_participants.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB
ab_participants.duplicated().sum()
0
len(list(set(ab_participants.query('group=="A" and ab_test=="recommender_system_test"')['user_id']) & set(ab_participants.query('group=="B" and ab_test=="recommender_system_test"')['user_id'])))
0
В details много пропущенных данных, скорее всего это необязательное поле с дополнительными данными
Группы в тесте не пересекаются
Проверим соответствие данных требованиям технического задания
recommender_system_test;product_page,product_cart,purchase.ab_participants['ab_test'].unique()
array(['recommender_system_test', 'interface_eu_test'], dtype=object)
В данных присутствует как минимум разметка двух тестов, но есть необходимоый нам 'recommender_system_test'
ab_participants[ab_participants['ab_test']=='recommender_system_test'].groupby('group').count()
| user_id | ab_test | |
|---|---|---|
| group | ||
| A | 3824 | 3824 |
| B | 2877 | 2877 |
Есть 3824 участника контрольной группы и 2877 участников новой платежной воронки
new_users[new_users['user_id'].isin(ab_participants[ab_participants['ab_test']=='recommender_system_test']\
['user_id'])]['first_date'].min()
Timestamp('2020-12-07 00:00:00')
Дата появления первого нового пользователя эксперимента - 7 декабря 2020 года - совпадает с ТЗ
new_users[new_users['user_id'].isin(ab_participants[ab_participants['ab_test']=='recommender_system_test']\
['user_id'])]['first_date'].max()
Timestamp('2020-12-21 00:00:00')
Дата окончания набора новых пользователей - 21 декабря 2020 года - совпадает с ТЗ
new_users[new_users['user_id'].isin(ab_participants[ab_participants['ab_test']=='recommender_system_test']\
['user_id'])].groupby('region')['user_id'].count()
region APAC 72 CIS 55 EU 6351 N.America 223 Name: user_id, dtype: int64
В данных почти все участники из EU (94%), по ТЗ должно было быть 15%. Не соответствует ТЗ
new_users[new_users['user_id'].isin(ab_participants[ab_participants['ab_test']=='recommender_system_test']\
['user_id'])]['user_id'].count()
6701
Общее число участников 6701, превышает ожидаемые по ТЗ 6000
marketing_events.query('start_dt>="2020-12-07" and start_dt<="2020-12-21"').sort_values(by='start_dt')
| name | regions | start_dt | finish_dt |
|---|
Никакие маркетинговые события не проходили в период действия эксперимента
marketing_events.sort_values(by='start_dt')
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 6 | Chinese New Year Promo | APAC | 2020-01-25 | 2020-02-07 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 8 | International Women's Day Promo | EU, CIS, APAC | 2020-03-08 | 2020-03-10 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 7 | Labor day (May 1st) Ads Campaign | EU, CIS, APAC | 2020-05-01 | 2020-05-03 |
| 9 | Victory Day CIS (May 9th) Event | CIS | 2020-05-09 | 2020-05-11 |
| 11 | Dragon Boat Festival Giveaway | APAC | 2020-06-25 | 2020-07-01 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
| 13 | Chinese Moon Festival | APAC | 2020-10-01 | 2020-10-07 |
| 12 | Single's Day Gift Promo | APAC | 2020-11-11 | 2020-11-12 |
| 5 | Black Friday Ads Campaign | EU, CIS, APAC, N.America | 2020-11-26 | 2020-12-01 |
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 10 | CIS New Year Gift Lottery | CIS | 2020-12-30 | 2021-01-07 |
На небольшом количестве маркетинговых событий можем проверить "глазами", что период действия ни одного из маркетинговых событий не пересекся с периодом от начала до окончания действия эксперимента.
Однако, если учесть период в 14 дней, который мы смотрим от даты регистрации пользователя, то пользователи, включенные в последние дни эксперимента, попадают на два маркетинговых события - Christmas&New Year Promo, CIS New Year Gift Lottery А значит их активность может отличаться от стандартной
len(list(set(ab_participants.query('group=="A" and ab_test=="recommender_system_test"')\
['user_id']) & set(ab_participants.query('group=="B" and ab_test=="recommender_system_test"')['user_id'])))
0
Группы в рамках проводимого теста не пересекаются
ab_participants[ab_participants['ab_test']=='recommender_system_test'].groupby('group').count()
| user_id | ab_test | |
|---|---|---|
| group | ||
| A | 3824 | 3824 |
| B | 2877 | 2877 |
Группа B составляет 43% от всей выборки, группа A - 57%. Группы не идентичны по размеру
len(list(set(ab_participants.query('ab_test=="recommender_system_test"')['user_id']) & set(ab_participants.query('ab_test=="interface_eu_test"')['user_id'])))
1602
Есть 1602 пользователя, которые участвуют сразу в двух тестах. Чтобы оценить влияние только одного теста, нужно исключить их из исследования
intersect_list = list(set(ab_participants.query('ab_test=="recommender_system_test"')['user_id']) \
& set(ab_participants.query('ab_test=="interface_eu_test"')['user_id']))
our_users=ab_participants.query('not(user_id.isin(@intersect_list)) and (ab_test=="recommender_system_test")')
our_users.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 5099 entries, 0 to 6700 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 5099 non-null object 1 group 5099 non-null object 2 ab_test 5099 non-null object dtypes: object(3) memory usage: 159.3+ KB
Осталось 5099 участников теста
our_users[our_users['ab_test']=='recommender_system_test'].groupby('group').count()
| user_id | ab_test | |
|---|---|---|
| group | ||
| A | 2903 | 2903 |
| B | 2196 | 2196 |
57% из группы А и 43% из группы B, процентное соотношение сохранилось
users_A = our_users[our_users['group']=='A']
users_B = our_users[our_users['group']=='B']
events.query('user_id.isin(@users_A.user_id)').groupby('user_id', as_index=False)['event_dt'].count().hist()
plt.title('Распределение количества событий в группе А')
plt.xlabel('Кол-во событий')
plt.ylabel('Кол-во пользователей')
plt.show();
events.query('user_id.isin(@users_B.user_id)').groupby('user_id', as_index=False)['event_dt'].count().hist()
plt.title('Распределение количества событий в группе B')
plt.xlabel('Кол-во событий')
plt.ylabel('Кол-во пользователей')
plt.show();
Распределение количества событий на пользователя в группах А и B выглядит по-разному.
events['event_date']=pd.to_datetime(events['event_dt']).dt.date
events_a =events.query('user_id.isin(@users_A.user_id)').groupby('event_date', as_index=False)['user_id'].count()
plt.figure(figsize=(15,8))
plt.plot(events_a['event_date'], events_a['user_id'])
plt.title('События группы А')
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.show()
Для группы А наблюдается небольшое количество событий (200+) от начала эксперимента до 13 декабря, затем резкий рост, пик на 21 декабря (1600) и спад до 29 декабря
events_b =events.query('user_id.isin(@users_B.user_id)').groupby('event_date', as_index=False)['user_id'].count()
plt.figure(figsize=(15,8))
plt.plot(events_b['event_date'], events_b['user_id'])
plt.title('События группы B')
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.show()
Количество событий для группы B на всем протяжении эксперимента не превышает 350, с пиком 21 декабря. До этого колеблется от 100 до 300 событий, падает до нуля к 30 декабря
events_a=events.query('user_id.isin(@users_A.user_id)').groupby('event_name',as_index=False)\
['user_id'].count().sort_values(by='user_id', ascending=False)
events_a
| event_name | user_id | |
|---|---|---|
| 0 | login | 6525 |
| 2 | product_page | 4250 |
| 1 | product_cart | 1989 |
| 3 | purchase | 1973 |
fig = go.Figure(
go.Funnel(
y=events_a['event_name'],
x=events_a['user_id'],
)
)
fig.update_layout(title="Воронка группы А")
fig.show()
Воронка группы А: от логина до покупки
100% - 65.1% - 30.5% - 30.1%
events_b=events.query('user_id.isin(@users_B.user_id)').groupby('event_name',as_index=False)['user_id'].count().sort_values(by='user_id', ascending=False)
events_b
| event_name | user_id | |
|---|---|---|
| 0 | login | 1976 |
| 2 | product_page | 1048 |
| 1 | product_cart | 533 |
| 3 | purchase | 510 |
fig = go.Figure(
go.Funnel(
y=events_b['event_name'],
x=events_b['user_id'],
)
)
fig.update_layout(title="Воронка группы А")
fig.show()
Воронка группы B: от логина до покупки
100% - 53% - 27% - 25.8%
Особенности данных: для группы А, состоящей из 2903 пользователей, значительно больше событий, чем для группы B, состоящей из 2196 пользователей.
Уже первый этап воронки показывает, что логинов группы А значительно больше (6525), чем для группы B (1976)
Пики количества событий в группах приходятся на одни и те же даты, но в разы отличаются по количеству.
Распределение количества событий по пользователям в группах А и B разное. Сравнивая максимумы по количеству пользователей - около 500 пользователей группы А совершает порядка 10 событий. И только 300 пользователей группы B совершает 5 событий.
Из 2903 зарегистрированных пользователей группы А покупку совершили 1973 пользователя
Из 2196 зарегистрированных пользователей группы B покупку совершили 510 пользователей
Проверим гипотезу о разнице в конверсии регистраций в заказ для этих двух групп
alpha = .05 # критический уровень статистической значимости
successes = np.array([1973, 510])
trials = np.array([2903, 2196])
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
p-значение: 0.0 Отвергаем нулевую гипотезу: между долями есть значимая разница
Из 6525 логинов пользователей группы А последовало 4250 просмотров карточки товаров
Из 1976 логинов пользователей группы B последовало 1048 просмотров карточки товаров
Проверим гипотезу о разнице в конверсии логинов в просмотр карточек товаров для этих двух групп
alpha = .05 # критический уровень статистической значимости
successes = np.array([4250, 1048])
trials = np.array([6525, 1976])
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
p-значение: 0.0 Отвергаем нулевую гипотезу: между долями есть значимая разница
Из 6525 логинов пользователей группы А последовало 1989 просмотров корзины
Из 1976 логинов пользователей группы B последовало 533 просмотров корзины
alpha = .05 # критический уровень статистической значимости
successes = np.array([1989, 533])
trials = np.array([6525, 1976])
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
p-значение: 0.0027738834626587217 Отвергаем нулевую гипотезу: между долями есть значимая разница
По воронке видно, а также подтверждено статистическими тестами, что улучшения метрики не менее, чем на 10% не произошло
Между конверсиями групп А и B есть значимая разница, но в группе B результат хуже
Мы исследовали результаты проведения А/B тестирования для эксперимента по ТЗ:
recommender_system_test;product_page,product_cart,purchaseИсследование состояло из нескольких этапов:
Общий вывод по результатам A/B тестирования